Skip to content

[DateRangeCalendar] Use Pointer Events for drag editing - #22279

Merged
LukasTy merged 24 commits into
mui:masterfrom
LukasTy:claude/kind-hofstadter-ba1396
May 19, 2026
Merged

[DateRangeCalendar] Use Pointer Events for drag editing#22279
LukasTy merged 24 commits into
mui:masterfrom
LukasTy:claude/kind-hofstadter-ba1396

Conversation

@LukasTy

@LukasTy LukasTy commented Apr 30, 2026

Copy link
Copy Markdown
Member

Summary

Drag-to-edit on the range start/end day was implemented with native HTML5 drag-and-drop, with a parallel touch-event path layered on top to make it usable on mobile. On iOS Safari, the drag UX was poor: native dragstart requires a ~500ms long-press to distinguish from scroll, and that delay is fixed by the platform — no CSS or setData tweak shortens it.

This PR replaces the entire drag mechanism with Pointer Events, so mouse, touch, and pen all flow through one code path. Tap-and-drag on iOS is now immediate (no long-press), matching the feel of React Aria's useMove / useCalendarCell.

How it works

  • pointerdown on a range-endpoint day starts tracking the gesture and releases the implicit touch pointer-capture, so subsequent pointerover events fire on the cells the finger crosses (same trick usePress uses).
  • The first pointerover on a different cell flips the drag state on, notifies the parent of the source endpoint via onDatePositionChange, and starts updating the preview range. Until then, the press is indistinguishable from a tap — rangePosition stays untouched so the regular click handler can advance it normally.
  • A document-level pointerup listener resolves the drop target from event.target (the actual element under the pointer at release) and commits, unless the target isn't a day cell (released into a gap or off the calendar → cancel) or the day is disabled.
  • A document-level pointercancel listener treats UA-interrupted gestures after real movement as a commit (spec intent of pointercancel), with the last hovered cell as the drop target.
  • A document-level keydown listener cancels on Escape — only consuming the key when a visible drag is in flight, so host modals/popovers can still close on Escape during an idle press.
  • For touch pointers, a non-passive touchmove listener is registered on the owner document to suppress page scroll once the finger crosses cell boundaries (touch-action: none on the source cell alone isn't enough). Mouse/pen don't need it.
  • A capture-phase one-shot click suppressor with stopImmediatePropagation prevents the synthesized post-pointerup click from re-entering the day's selection logic and undoing the drop.

What changed

  • useDragRange.ts — full rewrite around Pointer Events. Re-entrancy guards, owner-document listener binding, pointercancel recovery, disabled-day guard, malformed-data-timestamp guard, Escape cancellation, and per-touch eager scroll suppression.
  • DateRangePickerDay.tsx — stop forwarding the draggable prop to the underlying DOM element. The prop continues to drive the cursor: grab styling via isDayDraggable ownerState; the HTML attribute is no longer needed since we don't use native HTML5 drag.
  • DateRangeCalendar.test.tsx — drag tests fire pointer events instead of drag events. MockedDataTransfer is no longer needed. New tests cover multi-touch rejection, stuck-state recovery, pointercancel commit-after-move, post-cancel re-entrancy, click suppression, Escape cancellation, release-outside-any-cell cancellation, disabled-day rejection, and the first-move position handoff.
  • test/utils/pickers/calendar.tsexecuteDateDrag and executeDateDragWithoutDrop drive cells with pointerDown/pointerOver/pointerUp and no dataTransfer. Public test-helper API unchanged.

The internal hook signature changed (returns onPointerDown + onPointerOver, no more onDragStart/onDragEnter/onDrop etc.). The hook is internal so this is a non-breaking change for consumers.

Test plan

  • pnpm typescript — clean
  • pnpm eslint — clean on changed files
  • pnpm test:unit --project x-date-pickers-pro (UTC) — 35 passed, baseline 5 pre-existing local-timezone flakes only
  • pnpm test:browser --project x-date-pickers-pro (real Chromium) — all 15 dragging tests pass
  • iOS Safari (real device): tap-and-drag a range endpoint, immediate drag, focus moves to the dropped cell, range flip works
  • Android Chrome (real device): same scenarios

🤖 Generated with Claude Code

…events

The hook used to run two parallel paths: HTML5 drag handlers for desktop
and a custom touch path for mobile. The touch path predates iOS 15 native
drag-and-drop support and is no longer necessary.

Rely on drag events alone, with two tweaks lifted from Pragmatic Drag and
Drop's element adapter so drag works on touch devices:

- Always call `setData` on dragstart (iOS 15 silently swallows subsequent
  drag events otherwise).
- Set both `draggingDate` (custom key, used by the same-date drop guard)
  and `text/plain` (Android Chrome will not fire `dragover`/`drop`
  without `text/plain` or `text/uri-list` in the dataTransfer).

Drops support for iOS 14 and pre-Chromium Android, both of which never
worked reliably with the touch fallback either.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@code-infra-dashboard

code-infra-dashboard Bot commented Apr 30, 2026

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-22279--material-ui-x.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-pro 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-premium 0B(0.00%) 0B(0.00%)
@mui/x-charts 0B(0.00%) 0B(0.00%)
@mui/x-charts-pro 0B(0.00%) 0B(0.00%)
@mui/x-charts-premium 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers-pro 🔺+170B(+0.05%) 🔺+54B(+0.07%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 0B(0.00%) 0B(0.00%)
@mui/x-license 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@LukasTy LukasTy added plan: Pro Impact at least one Pro user. scope: pickers Changes related to the date/time pickers. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. labels Apr 30, 2026
@LukasTy LukasTy self-assigned this Apr 30, 2026
LukasTy and others added 5 commits April 30, 2026 18:03
…press

iOS Safari was intercepting the long-press on a draggable day as text-
selection intent, never firing `dragstart`. The HTML `draggable="true"`
attribute alone doesn't enable in-page drag on iOS — WebKit needs
`-webkit-user-drag: element`, plus the text-selection UI suppressed so it
doesn't race the drag intent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`touch-action: none` was added to suppress browser default touch handling
during the custom touch-event drag path. With that path gone, it can
prevent iOS WebKit from registering a long-press as drag intent, since
the browser uses default touch behavior to detect the gesture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t the drag

iOS Safari aborts an in-flight HTML5 drag if the source element's DOM
mutates during `dragstart` — and our handler was synchronously calling
`setRangeDragDay`, `setIsDragging`, and `onDatePositionChange`, each
re-rendering the day grid (toggling `data-position`, recomputing the
dragging-range highlight, etc.) right inside the dragstart event.

React Aria's `useDrag` waits a frame before flipping its dragging state
for the same reason. Mirror that here by deferring the React state
updates to `requestAnimationFrame`. To keep the synchronous gate that
prevents `dragenter` / `dragover` / `drop` from acting outside an active
drag (e.g. a stray external file drag), track an `isDraggingRef`
alongside the state — set synchronously, read in the gate, and the React
state is just for re-rendering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without it, iOS Safari treats a quick swipe on a draggable day as page
scroll instead of waiting for the long-press timer to confirm drag
intent. With it, touches on draggable cells are committed to drag
(after the native ~500ms long-press) and won't accidentally scroll the
page. Touches on non-draggable cells are unaffected — only the
isDayDraggable variant gets `touch-action: none`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Native HTML5 drag-and-drop on iOS Safari requires a ~500ms long-press
gesture before `dragstart` fires (the browser's way of distinguishing
drag from scroll). That's a fundamental property of the platform and
can't be tweaked with CSS or rAF tricks — it shipped that way and stays
that way.

React Aria's `useMove` and the per-cell drag in their `useCalendarCell`
both bypass native drag entirely on touch by using Pointer Events,
which makes drag start as soon as the pointer leaves its initial
position. Mirror that pattern here.

The new flow:

- `pointerdown` on a range-endpoint day starts the drag immediately
  (no delay, no long-press) and releases the implicit pointer capture
  so sibling cells can fire `pointerover` as the finger moves across
  the grid — same trick `usePress` uses.
- `pointerover` on a cell during the drag updates the preview range.
- A document-level `pointerup` listener commits the drop.
- A document-level `touchmove` listener with `preventDefault` keeps
  the page from scrolling while the drag is in flight.
- A capture-phase one-shot `click` suppressor prevents the synthesized
  click after a moved drag from re-entering the day's selection logic.
- A no-op `onDragStart` cancels the browser's native drag so it doesn't
  draw a ghost on top of our pointer-driven gesture.

The hook keeps its existing return shape semantically (`isDragging`,
`rangeDragDay`, `draggingDatePosition`, plus event handlers to spread
on cells) so `DateRangeCalendar` doesn't change.

Tests use Pointer Events via `fireEvent.pointerDown` / `.pointerOver` /
`.pointerUp`. `MockedDataTransfer` is no longer needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@LukasTy LukasTy changed the title [DateRangeCalendar] Replace touch handlers with mobile-friendly drag events [DateRangeCalendar] Use Pointer Events for drag editing May 1, 2026
`handlePointerDown` was synchronously calling `onDatePositionChange` (and
flipping `isDragging` / `rangeDragDay`) on every press of a range
endpoint. That mutated `rangePosition` even for pure taps, and the click
that followed the tap then routed into the wrong side of the range —
breaking the e2e flow that taps an existing endpoint twice to collapse
the range to a single day. The Android Chrome e2e
"should allow re-selecting value to have the same start and end date"
caught this.

In the original HTML5-drag implementation, `dragstart` only fired after
real movement, so taps were invisible to the hook. Mirror that: stash
the source date / position / pointerId on `pointerdown`, but wait until
`pointerover` reports a *different* cell before activating drag UI and
notifying the parent of the source endpoint. A press without movement
never touches React state or `rangePosition`, so the click handler runs
with the calendar's natural state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@LukasTy
LukasTy marked this pull request as ready for review May 4, 2026 12:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates DateRangeCalendar’s drag-to-edit interaction from native HTML5 drag-and-drop (plus a separate touch path) to a unified Pointer Events implementation, improving mobile UX (notably eliminating iOS Safari’s long-press drag delay).

Changes:

  • Rewrote useDragRange to drive range endpoint dragging via pointerdown/pointerover with document-level pointerup commit handling and scroll suppression during the gesture.
  • Updated DateRangePickerDay draggable styling/behavior to align with the new pointer-driven gesture and avoid native side-effects (selection/callouts).
  • Updated unit tests and test utilities to replay drags using pointer events instead of drag/touch events.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
test/utils/pickers/calendar.ts Replaces drag-event test helpers with pointer-event drag replay helpers.
packages/x-date-pickers-pro/src/DateRangePickerDay/DateRangePickerDay.tsx Adjusts draggable-day CSS to better support pointer dragging (no scrolling/selection callouts).
packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts Core rewrite: pointer-driven drag tracking, preview updates on pointerover, and commit/cancel via document listeners.
packages/x-date-pickers-pro/src/DateRangeCalendar/DateRangeCalendar.test.tsx Migrates drag tests from DataTransfer-based drag events to pointer events; removes obsolete touch-path tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts Outdated
LukasTy and others added 5 commits May 4, 2026 17:22
…ters

A second pointerdown arriving while a drag is already in flight
(multi-touch, pen joining a touch, second finger tap) would overwrite
`pointerIdRef` and `cleanupListenersRef`, leaking the first gesture's
document listeners and silencing its `pointerup` because the id check
in the listener would no longer match. Bail early in that case so the
original gesture stays in control.

Two guards: `event.isPrimary === false` filters secondary multi-touch
pointers up front, and `pointerIdRef.current != null` covers the case
where some prior gesture is still considered active (also a recovery
path if its pointerup was somehow lost).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The day cell button no longer needs the HTML `draggable="true"`
attribute now that the drag is driven entirely by Pointer Events. The
attribute was only there so the browser would render the cell as a
drag source — which is exactly what we don't want anymore (the native
ghost would draw on top of our pointer-driven gesture).

The `draggable` prop on `DateRangePickerDay` stays accepted and still
drives the `isDayDraggable` ownerState (and therefore the `cursor:
grab` + `touch-action: none` + `user-select: none` styling). Only the
DOM attribute pass-through is removed.

Now that no native drag is initiated, the no-op `onDragStart` handler
in `useDragRange` that existed solely to suppress the ghost is gone
too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…handlePointerDown`

The `event.isPrimary === false` check broke browser-mode tests: real
`PointerEvent` constructed via `fireEvent.pointerDown(...)` defaults to
`isPrimary: false` (the constructor's default), so the test event was
short-circuited as if it were a secondary multi-touch pointer. jsdom
doesn't have a real `PointerEvent` constructor so the property stayed
`undefined` and the unit test passed — that's why CI caught it but
local Vitest did not.

The check was redundant: events are dispatched serially on a single
JS thread, so by the time a second pointerdown reaches us, the first
has already set `pointerIdRef`. The `pointerIdRef.current != null`
check alone covers multi-touch, pen+touch, and the "stuck state"
recovery scenario.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ests

The `event.isPrimary === false` short-circuit in `handlePointerDown` is
the right semantic guard against secondary multi-touch pointers — it
matches what real browsers produce. The earlier removal was a workaround
for `fireEvent.pointerDown` defaulting `isPrimary` to `false`, which the
tests were silently relying on. The cleaner fix is to make tests mimic
native behavior.

Pass `isPrimary: true` from the `executeDateDrag` helper and from the
inline child-element test, matching what a real first-finger touch /
mouse press dispatches. Production keeps both guards: `isPrimary` filters
secondary multi-touch up front, and `pointerIdRef.current != null`
covers pen+touch (each pointer type has its own primary) and the
"stuck state" recovery scenario.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trim the explanatory blocks added during the Pointer Events refactor.
Same content, fewer words: the JSDoc on `getClosestElementWithDataAttribute`
keeps the camelCase / kebab-case caveat (a real footgun); each handler
keeps a one-or-two-line "why" without restating what the code obviously
does. Net −22 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@LukasTy
LukasTy requested a review from a team May 12, 2026 13:19

@flaviendelangle flaviendelangle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review — mui/mui-x #22279

Title: [DateRangeCalendar] Use Pointer Events for drag editing
URL: #22279
Scope: 4 files, +178/−503 — full rewrite of useDragRange.ts, drops the parallel HTML5-drag + touch code paths, unifies under Pointer Events.

Reviewed via four parallel agents (code-reviewer, silent-failure-hunter, pr-test-analyzer, comment-analyzer), cross-referenced against the range-calendar branch on base-ui-plus which implements the same feature with a root-level pointer-capture + elementFromPoint architecture.

Files referenced below are paths within the PR repo (mui-x):

  • packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts
  • packages/x-date-pickers-pro/src/DateRangeCalendar/DateRangeCalendar.test.tsx
  • packages/x-date-pickers-pro/src/DateRangePickerDay/DateRangePickerDay.tsx
  • test/utils/pickers/calendar.ts

Critical (must fix before merge)

The architectural root cause beneath all three: the hook treats pointerup as a reliable terminator, which it is not on touch/pen hardware. Cleanup is keyed off pointerId equality with no watchdog, no lostpointercapture, no recovery path.

C1. Unmount mid-drag leaves state stuck

useDragRange.ts:256-261. The useEffect cleanup only removes document listeners; it does not reset isDraggingRef, pointerIdRef, pendingDropRef, sourceDateRef. If the component re-mounts (month nav, parent re-render of a different cell tree) pointerIdRef.current != null is still true → the handlePointerDown guard at L126 rejects every subsequent gesture. Also keeps pendingDropRef.current.target (an unmounted DOM node) alive.

Fix: null all refs in the unmount cleanup.

React.useEffect(
  () => () => {
    cleanupListenersRef.current?.();
    cleanupListenersRef.current = null;
    pointerIdRef.current = null;
    isDraggingRef.current = false;
    sourceDateRef.current = null;
    sourcePositionRef.current = null;
    didMoveRef.current = false;
    pendingDropRef.current = null;
  },
  [],
);

C2. No watchdog for lost pointerup — permanent jam

useDragRange.ts:208-216, :126. If the OS swallows both pointerup and pointercancel for the active pointerId (documented iOS / WebView quirk under system gesture, page hide, capture steal), document listeners stay attached forever and the L126 guard refuses every future gesture. The comment at L124-125 names "recovery from a lost pointerup" as a goal of the pointerIdRef check, but the check only refuses recovery rather than enabling it.

Fix (preferred): on the L126 guard hit, proactively call cleanup() and start the new gesture — a fresh primary pointerdown definitionally ends the previous one. Reference branch sidesteps this entirely via setPointerCapture on the root + lostpointercapture.

C3. pointercancel after a real move silently discards the drop

useDragRange.ts:193-198. When the user has dragged to a new cell, pointercancel (iOS system gesture, context-menu long-press, scroll-snap interrupt) calls cleanup() which unwinds UI but never fires onDrop. From the user's perspective: range snaps back, no explanation, no recovery.

Fix: treat pointercancel after didMoveRef === true identically to pointerup and commit the drop, OR expose an onDragCancel(sourceDate, lastPreviewDate) callback. Spec intent of pointercancel is "UA interrupted the gesture, not the user".


Important

I1. Drag listeners bind to top-level document instead of ownerDocument

useDragRange.ts:181-184, :208-215. Breaks iframe-hosted pickers. The reference branch (RangeCalendarStore.handlePointerMove) routes everything through ownerDocument(this.rootRef.current).

Fix: const ownerDoc = event.currentTarget.ownerDocument ?? document; at the top of handlePointerDown, use for all four listeners and the click suppressor.

I2. No Escape-key cancellation while a drag is in progress

useDragRange.tsuseDragRangeEvents, no keydown handler at all. Native HTML5 DnD provided this for free; pointer-events implementations must wire it explicitly. Accessibility regression. Reference branch wires this in RangeCalendarStore.handleKeyDown.

Fix: add a document-level keydown listener in handlePointerDown that calls cleanup() on Escape.

I3. iOS text-selection magnifier not suppressed

useDragRange.ts:115-217. WebkitTouchCallout: none (DateRangePickerDay.tsx:228) handles the callout, but the iOS magnifier is a separate feature; the canonical fix is event.preventDefault() inside pointerdown. Reference impl does this.

Fix: add event.preventDefault() after the stopPropagation() at L145.

I4. releasePointerCapture unguarded against DOMException

useDragRange.ts:138-143. Safari 15 / some Android WebViews throw InvalidPointerId between the hasPointerCapture check and the releasePointerCapture call (non-atomic). The throw escapes handlePointerDown and the gesture silently fails to start. Reference branch wraps both calls in try/catch.

Fix: wrap in try/catch — it's a benign "already released" race.

I5. touchmove listener installed even on tap-without-move

useDragRange.ts:200-210. Non-passive touchmove on document is registered at pointerdown, before any movement. For every endpoint tap (very common — that's how you advance the range), the page loses compositor-thread scrolling until the user lifts.

Fix: defer the touchmove listener registration until didMoveRef flips to true inside handlePointerOver. The internal isDraggingRef check then becomes redundant.

I6. onDatePositionChange silently skipped if data-position missing

useDragRange.ts:244-248, :153-154. If a custom day-slot strips data-position, the cast (position as RangePosition | undefined) ?? null resolves to null, the dispatch is silently skipped, but setIsDragging(true) still fires — preview computes against the wrong endpoint with no diagnostic.

Fix: when sourcePositionRef.current is null at activation time, either abort drag entry or console.warn in dev mode.

I7. resolveDateFromTarget throws RangeError on non-numeric data-timestamp

useDragRange.ts:49-66. new Date(NaN).toISOString() throws; Number(non-numeric) is NaN. Throw mid-pointerover wedges the gesture and never reaches cleanup().

Fix: guard Number.isFinite(timestamp) before passing to adapter.date; return null otherwise.

I8. Two-listener race in synthetic-click suppressor

useDragRange.ts:176-184. Rapid back-to-back drags can leave two suppressClick closures racing on document; a stray unrelated click in the macrotask window can be silently consumed by the wrong gesture's suppressor.

Fix: track outstanding suppressors in a ref and replace, or rely on { once: true } + the setTimeout as belt-and-suspenders only.


Test gaps

# Behaviour File:Line Risk
T1 Tap-vs-drag distinction (didMoveRef deferred activation) useDragRange.ts:156-158, :241-249 A "simplification" that moves setIsDragging(true) back into handlePointerDown would silently break tap-to-advance on touch; no test fails.
T2 pointercancel cleanup useDragRange.ts:193-198 Forgetting the listener leaks pointerup/touchmove forever; no test fails.
T3 Re-entrant pointerdown + non-primary rejection useDragRange.ts:118, :126 Three guards (button > 0, isPrimary === false, pointerIdRef != null) all untested.
T4 pointerover === currentTarget early-return useDragRange.ts:227 Removing the guard re-introduces redundant setRangeDragDay calls per pointer-over on the same cell — measurable perf regression.
T5 Capture-phase click suppressor useDragRange.ts:176-184 Current executeDateDrag never fires click, so the suppressor is never exercised.
T6 touchmove.preventDefault on document useDragRange.ts:200-210 A "tidy-up" that moves the listener will silently re-enable scroll-while-drag on mobile.

The PR's claim that "mouse, touch and pen flow through one code path so one test suite suffices" is true for the main handlePointerDown body, false for the touch-specific defenses (implicit capture release, document-level touchmove, jsdom hasPointerCapture guard). Deleting the touch-specific tests without adding behavioural assertions for these branches is the central testing concern.

Other test issues

  • T7. The new "child elements" test is a near no-opDateRangeCalendar.test.tsx:247-273. React delegation supplies event.currentTarget = button regardless of which child the user touched, so getClosestElementWithDataAttribute short-circuits on the button itself and never walks. Either rewrite the production code to read event.target, or rewrite the test to call the helper directly, or delete it.
  • T8. executeDateDrag fires pointerUp on documentcalendar.ts:24. Technically valid (production listens on document) but a regression that moves the listener back to the cell would silently keep passing in jsdom but break in real browsers. Consider firing pointerup on the last otherDate and letting it bubble.
  • T9. Missing clientX/clientY on synthetic pointer events — ticking time-bomb if a drag-threshold is ever added.
  • T10. pointerId: 1 magic number duplicated in calendar.ts and the test file (instead of importing the helper constant).
  • T11. Repo-wide grep needed for stale buildPickerDragInteractions callers — public test-utility rename.

Comment issues

Inaccurate

  • useDragRange.ts:116-117 — The > 0 rationale is fabricated. jsdom's MouseEventInit.button defaults to 0, not undefined. The real reason to prefer > 0 is that real browsers send button: -1 on events where no button changed state (e.g. pointerover while a primary press is held); !== 0 would falsely flag those. Rewrite around that.
  • DateRangeCalendar.test.tsx:248-250 — "The handler must walk up to the button to read its data attributes — exercise that path explicitly." The handler reads event.currentTarget (the bound button), so no walking happens. Comment is rotted relative to the code it documents.
  • useDragRange.ts:122-125 — "The pointerIdRef check also covers pen+touch (each pointer type has its own primary)" — slightly misleading: pen+touch is covered only by pointerIdRef, not by the isPrimary half. Tighten.

Coupling / rot risk

  • useDragRange.ts:219-221 — Drop the "testing-library fireEvent" half. The production rationale (React synthesizes enter/leave from over/out) is durable and sufficient.
  • useDragRange.ts:156-158 — References handlePointerOver and rangePosition by name; neither will survive a refactor cleanly. Generic phrasing is fine.
  • calendar.ts:5-11 — Docstring above executeDateDragWithoutDrop says "...then pointerup", but that variant explicitly does not fire pointerup. Trim.

Good (keep)

The bulk of the comments do real work: the implicit-capture release rationale (L135-137), the cleanup re-render skip (L106), the swallow-click strategy (L172-175), the touch-action-isn't-enough explanation (L200-202), and the camelCase/kebab-case JSDoc on getClosestElementWithDataAttribute (L32-36) are all exactly the kind of comments that earn their keep in this file.


Strengths

  • Sound state machine. Each ref has a single documented purpose; cleanup is centralised.
  • Defer-drag-UI-until-first-move cleanly separates tap-on-endpoint from drag-from-endpoint — the comment at L156-158 documents this invariant.
  • pendingDropRef.current?.target === event.currentTarget early-return prevents redundant state updates on repeated pointerover.
  • hasPointerCapture feature-detect correctly handles jsdom.
  • Document-level touchmove.preventDefault closes the iOS scroll-while-drag gap that touch-action: none alone leaves once the finger crosses cell boundaries.
  • Removed dead code (emptyDragImgRef, MockedDataTransfer, resolveButtonElement, resolveElementFromTouch, rangeCalendarDayTouches coord table) — net simplification.
  • No as any casts introduced. The RangePosition cast is properly guarded with ?? null.
  • Flip / reduce / expand happy paths and shouldDisableDate mid-drag reactivity are well covered.

Recommended action plan

  1. Block on: C1, C2, C3 (critical state-machine bugs).
  2. Address before merge: I1 (ownerDocument), I2 (Escape), I4 (releasePointerCapture try/catch), I7 (Number.isFinite guard).
  3. Add tests: T1, T2, T3 minimum; T5/T6 strongly recommended. Decide T7 (rewrite vs. delete).
  4. Comment fixes: the three inaccurate items.
  5. Architectural note (not blocking): the reference range-calendar branch (base-ui-plus) uses root-level setPointerCapture + elementFromPoint, which sidesteps C2/C3 by design and gets lostpointercapture as a free recovery signal. If you're going to ship more pointer-driven gestures in mui-x pickers, that pattern is worth lifting wholesale rather than continuing per-hook cleanup orchestration.
  6. Manual cross-browser smoke required: iOS Safari 15/16/17, Android Chrome. jsdom does not simulate pointer-capture semantics — the releasePointerCapture + pointerover-on-siblings load-bearing assumption is unverifiable in CI.

LukasTy and others added 3 commits May 13, 2026 16:08
Addresses the review on PR mui#22279. Treats `pointerup` as a possibly-unreliable
terminator, adds the recovery paths the previous version was missing, and
plugs several iframe / re-entrancy / parsing footguns.

Critical
- Unmount cleanup now nulls every gesture ref (not just listeners), so a
  remount or a mid-drag cell tree replacement can start fresh and detached
  DOM nodes referenced via `pendingDropRef` can be garbage-collected.
- Re-entrant primary pointerdowns recover instead of refusing: a fresh
  primary pointer definitionally ends any prior gesture (covers pen+touch,
  covers a lost pointerup), so we `cleanup()` and continue rather than
  short-circuiting and jamming the hook permanently.
- `pointercancel` after real movement now commits the drop. The spec
  intent is "UA interrupted, not the user"; the snap-back was otherwise
  silent and inexplicable.

Important
- All document-level listeners (`pointerup`, `pointercancel`, `keydown`,
  `touchmove`, click suppressor) now bind to `event.currentTarget.ownerDocument`
  so iframe-hosted pickers work.
- Escape on `document` cancels an in-flight drag (accessibility parity
  with the native HTML5 drag we replaced).
- `releasePointerCapture` is wrapped in try/catch — Safari 15 / some
  Android WebViews race between `hasPointerCapture` and the release call
  and throw `InvalidPointerId`.
- `resolveDateFromTarget` guards `Number.isFinite(timestamp)` so a
  malformed `data-timestamp` returns null instead of throwing
  mid-`pointerover` and wedging the gesture.
- The `touchmove`-blocks-scroll listener is now installed lazily on the
  first real move (not on every press), so pure endpoint taps don't
  disable compositor-thread scrolling.
- The click suppressor tracks its outstanding teardown in a ref and
  tears the previous one down before installing a new one, so
  back-to-back drags can't race two suppressors on the document.
- When `data-position` is missing on the source cell (custom day slot),
  activation aborts with a dev-mode warning instead of computing the
  preview against the wrong endpoint.

Tests
- `executeDateDrag` helper docstring no longer claims it fires pointerup.
- Deleted the "child elements inside the day button" test — React
  delegation supplies `currentTarget = button`, so the walk-up helper
  short-circuits on the button itself; the test exercised nothing the
  other drag tests don't already cover.
- New: secondary multi-touch rejection (`isPrimary === false`), fresh-
  primary stuck-state recovery, commit-on-pointercancel-after-move,
  no-commit-on-pointercancel-before-move, post-pointercancel hook still
  drag-capable, click suppression after a moved drag, Escape cancels
  an in-flight drag.

Comment / docstring cleanup
- `button > 0` rationale corrected: real browsers report `button: -1` on
  events where no button changed state.
- The pen+touch coverage note moved off the `isPrimary` half (which
  doesn't cover it) onto the `pointerIdRef` recovery branch.
- The `pointerover`-vs-`pointerenter` comment drops the testing-library
  footnote.
- The "deferred to handlePointerOver" comment is now phrased generically
  so a rename doesn't rot it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
It only touches refs, so its closure is stable across renders. Wrapping
gives it stable identity (matching the other handlers in this hook) and
saves recreating the closure on every parent re-render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LukasTy and others added 2 commits May 14, 2026 10:45
…ide any cell

Restores the cancel-by-releasing-outside-any-target convention native HTML5
drag provides for free. Previously, if the user dragged onto a cell and then
slid off the calendar before lifting, the gesture would silently commit the
last cell they happened to hover — surprising on a release that the user
likely intended as a cancel.

Add `onPointerOut` to the per-cell handler set. When the pointer leaves a
cell into something that isn't another cell (gap, header, outside the
calendar), forget the would-drop target. If the pointer enters another
cell next, that cell's `pointerover` re-sets the target; if it doesn't,
`pointerup` sees null and skips the drop. Moving to a descendant of the
cell (text span, etc.) is treated as still-inside and ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…terup itself

Per-cell `onPointerOut` for the strict-cancel behavior was over-engineered.
Since we listen on `document` for pointerup and the pointer has its capture
released, the bubbled event already carries the actual element under the
pointer at release time. Walk up from `event.target` to find the day cell
(or null if released into a gap / off the calendar) and drop accordingly.

- Removed `handlePointerOut` and the `onPointerOut` slot prop. The day-cell
  handler set is now just `onPointerDown` + `onPointerOver`.
- `pendingDropRef` collapsed into `lastHoveredCellRef`: it was tracking
  two things (dedupe and would-drop), but now drop comes from the
  pointerup event itself. The remaining ref is only used to (a) dedupe
  `pointerover` within the same cell and (b) fall back as the drop
  target on `pointercancel`, whose `event.target` is unreliable across
  browsers.
- `finalizeGesture` now takes the event and a type tag ('pointerup' vs
  'pointercancel') and resolves the drop accordingly.
- Test helper fires `pointerup` on the destination cell (it bubbles to
  our document-level listener) instead of on `document` directly,
  matching real-browser behavior.
- The "release outside any cell" test fires `pointerup` on
  `document.body` — non-cell target → resolves to null → no drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts:211

  • The inline comment justifies > 0 (rather than !== 0) by stating "real browsers report button: -1 on events where no button changed state". That -1 convention applies to pointermove/pointerover, not to pointerdown — a pointerdown always represents a button state change and will report button >= 0. The reasoning given here is therefore inaccurate; the check is fine functionally (it accepts primary button = 0 and rejects secondary buttons), but the comment is misleading and worth tightening.
    // Ignore secondary mouse buttons. `> 0` (not `!== 0`) is intentional:
    // real browsers report `button: -1` on events where no button changed
    // state, and we want to treat those as primary.
    if (event.button > 0) {

packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts:262

  • event.currentTarget.dataset is read directly here, but resolveDateFromTarget (used in handlePointerDown above) still routes through getClosestElementWithDataAttribute. If anyone ever attaches onPointerDown from this hook to a wrapper that doesn't carry data-position on the same element that carries data-timestamp, drag will silently abort on first move because sourcePositionRef.current is unset. Since the previous implementation explicitly used getClosestElementWithDataAttribute(event.currentTarget, 'position') for resilience, consider keeping that traversal here too rather than assuming the dataset is on currentTarget directly.
    const { position } = event.currentTarget.dataset;
    sourcePositionRef.current = (position as RangePosition | undefined) ?? null;

Comment thread packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts
LukasTy and others added 2 commits May 14, 2026 15:51
Address review feedback on PR mui#22279.

Material: `finalizeGesture` was committing whatever cell the `pointerup`
landed on, including disabled `<button>` cells (Chromium and WebKit
still fire pointerup on disabled buttons). `DateRangeCalendar.handleDrop`
doesn't re-validate the date, so dragging an endpoint onto a
`shouldDisableDate` / min-max / readOnly day produced an invalid range.
Guard explicitly: skip `onDrop` when the resolved drop cell is a
disabled `<button>`. Adds a regression test that drops on a
`shouldDisableDate`-disabled cell and asserts no `onChange`.

Comments:
- The `> 0` rationale on the `event.button` guard claimed real browsers
  send `button: -1` on no-state-change events — true for
  `pointermove`/`pointerover`, but not `pointerdown`, which always
  reports a real button state change. The check is still correct
  (synthetic events that leave `button` unset should be treated as
  primary); the explanation is now accurate.
- `event.currentTarget.dataset.position` is now read via
  `getClosestElementWithDataAttribute`, mirroring how `data-timestamp`
  is resolved. Resilient if a future slot puts `data-position` on a
  wrapper around the cell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.

Comment thread packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts
Comment thread packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts Outdated
Comment thread packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts Outdated
Comment thread packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts Outdated
Comment thread packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts Outdated
Comment thread packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts
- **stopImmediatePropagation in click suppressor.** Capture-phase click
  listeners registered on `document` by analytics, focus traps, or
  third-party overlays would otherwise still observe the synthesized
  post-drag click as if the user intentionally clicked the day cell.
- **Disabled-day guard no longer asserts the button cast.** Resolve the
  focusable `<button>` separately from the `data-timestamp` host via
  `.closest('button')` and `.querySelector('button')`. The disabled
  check and focus() target now work whether `data-timestamp` lives on
  the button itself (today) or on a future wrapper around it.
- **Eager `touchmove` for touch pointers.** Previously installed lazily
  on first cross-cell move, relying on the spec's `touch-action`
  latching behavior to suppress scroll until then. Real-world
  WebKit/Chromium versions don't reliably honor the latch. Attach
  eagerly when `pointerType === 'touch'`; mouse/pen don't need it.
- **Escape `preventDefault` gated on `didMoveRef`.** A press without
  movement is indistinguishable from a tap; let Escape propagate so a
  host modal/popover can close on the same key. Only consume Escape
  when there's a visible drag in flight.
- **Removed `event.stopPropagation()` from `handlePointerDown`.** It was
  defensive but unnecessary — no nested drag handlers exist in the
  calendar tree that would collide. Bubble now flows naturally to
  ButtonBase's ripple and any parent listeners.
- **Removed unused `ownerDocumentRef`** (only the lazy `touchmove`
  install read it).
- **Test:** verify `onRangePositionChange` fires on the first
  cross-cell move with the source endpoint, and *only* on the first
  move (the position handoff is what makes range flip work; the old
  touch path covered it implicitly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (4)

packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts:210

  • When the pointer is released after movement onto a disabled day or outside any cell, wasMoved is true so installClickSuppressor is still installed even though no drop was committed. The suppressor will then swallow the very next click anywhere in the document (within the same task tick before the setTimeout(teardown, 0) runs). For releases on disabled day buttons this is largely benign (disabled buttons don't fire click in browsers), but for releases outside the calendar entirely (e.g., on document.body or some sibling UI element), a legitimate post-release click on a different unrelated element could be swallowed. Consider gating installClickSuppressor on whether a commit actually occurred (or at minimum, only when dropCell was a real day cell), so a canceled gesture doesn't interfere with user interactions outside the calendar.
    if (eventType === 'pointerup' && wasMoved) {
      // The click that follows pointerup would re-enter the day's selection
      // logic and undo the drop; swallow it. (Not needed on pointercancel —
      // no click follows a canceled gesture.)
      installClickSuppressor(ownerDoc);
    }

packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts:407

  • On unmount, clearGestureState runs but clickSuppressorRef is not torn down. If the component unmounts in the narrow window between pointerup (which installs the suppressor) and the synthesized click / setTimeout(0) (which clears it), the capture-phase click listener remains attached to document referencing closures over the now-unmounted hook scope. The leak is short-lived (setTimeout(0) still fires) but it can also unintentionally swallow a click on unrelated UI that the parent navigates to after unmount. Consider tearing down clickSuppressorRef.current?.() in the unmount effect as well.
  // On unmount, clear gesture state so a remount can start fresh and any
  // detached DOM nodes still referenced by gesture refs can be GC'd.
  // `clearGestureState` is `useEventCallback`-stable, so the effect runs once.
  React.useEffect(() => () => clearGestureState(), [clearGestureState]);

packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts:332

  • The Escape handler always calls cleanup() even for idle presses (no movement), but only preventDefault()s for moved gestures. This means: if a user presses-and-holds on an endpoint cell (no drag yet) and the host modal/popover has an Escape-to-close handler, both the modal will close AND the in-flight press is silently torn down — including removal of the pointerup/pointercancel document listeners. The eventual pointerup then becomes a no-op for the hook, but the synthesized post-pointerup click still propagates normally (no suppressor was installed), so the day's tap-to-advance still fires. This may be intentional, but the consequence is that pressing Escape during an idle press leaves the gesture state half-collapsed (gesture listeners gone, but click logic still runs); please confirm this is the desired UX, and consider whether the idle-press case should leave the gesture alone entirely until pointerup.
    const onKeyDown = (keyEvent: KeyboardEvent) => {
      if (keyEvent.key !== 'Escape') {
        return;
      }
      // Only consume Escape when there's a visible drag in flight. A press
      // without movement is indistinguishable from a tap; let Escape
      // propagate so a host modal/popover can still close on the same key.
      if (didMoveRef.current) {
        keyEvent.preventDefault();
      }
      cleanup();
    };

packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts:242

  • event.button > 0 accepts negative values such as -1. The Pointer Events spec uses button: -1 to indicate "no button changed state" for pointermove/pointerover etc., and some test environments / synthetic events may set this on pointerdown as well. The comment says the permissive check is to allow synthetic events with button unset (undefined > 0 is false, OK), but -1 > 0 is also false, so a pointerdown with button: -1 would be treated as primary. Consider event.button !== 0 && event.button != null or explicit allow-list to be more defensive, since middle/right clicks with button: 1/2 are correctly rejected today but button: -1 is silently treated as a primary press.
    // Ignore secondary mouse buttons (middle = 1, right = 2). `> 0` rather
    // than `!== 0` keeps the gesture permissive when `event.button` is left
    // unset by a synthetic event (some test environments).
    if (event.button > 0) {
      return;
    }

Comment thread packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts

@siriwatknp siriwatknp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Looks good to me overall. The Pointer Events state machine is clear and the new tests cover the important edge cases. One small test coverage point:

(1) The disableDragEditing test (and the draggable attribute parts of the readOnly / disabled tests) no longer verifies behavior — draggable is removed before reaching the DOM.

LukasTy and others added 3 commits May 18, 2026 17:33
…h real drag attempts

The `disableDragEditing` test and the `readOnly` / `disabled` tests were
asserting that selected day cells did not carry the `draggable` HTML
attribute. After this PR removed the attribute forwarding entirely
(no day cell carries `draggable` regardless of state), those checks
pass trivially and prove nothing.

Replace each assertion with a real drag attempt that confirms `onChange`
is not called. `disabled` / `readOnly` cascade into
`shouldDisableDragEditing` inside the calendar, so the gesture must
short-circuit at `handlePointerDown` and never reach `onDrop`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address useful subset of Copilot review #4291310693:

- **Click suppressor gated on `dropCell`.** Previously installed on every
  `wasMoved` pointerup, including releases outside the calendar. With
  `stopImmediatePropagation` that could swallow a legitimate click on
  unrelated host UI. Install only when the release lands on a real day
  cell — covers the "drag returned to source" case (where suppression
  is genuinely needed to stop the click from rewriting the range), but
  no longer interferes when the user releases off the calendar.
- **`clickSuppressorRef` torn down in `clearGestureState`.** Without
  this, an unmount in the narrow window between `pointerup` and the
  `setTimeout(0)` teardown would leave a capture-phase listener on
  `document` long enough to swallow a click on whatever UI the parent
  navigates to.
- **Escape on idle press is a no-op for the hook.** Previously we
  cleaned up listeners even when there was no movement yet, but never
  suppressed the subsequent click — leaving the gesture half-collapsed
  (hook listeners gone, tap-to-advance still firing on release). Now
  Escape only consumes and cancels when a visible drag is in flight;
  an idle press is left alone and behaves as a tap on release.

Skipped Copilot's `event.button === -1` defensiveness suggestion: per
spec, `pointerdown` always reports a real button state change and never
fires with `button: -1`. The earlier Copilot review on this same PR
confirmed the point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes two Copilot review coverage gaps:

- Two tests fire `pointerDown` with `pointerType: 'touch'` and assert
  the document-level `touchmove` listener calls `preventDefault` (page
  scroll suppression). A companion test with `pointerType: 'mouse'`
  asserts the listener is *not* attached for mouse pointers.
- One test releases `pointerup` on a synthesized child element appended
  inside a day button — exercises the `getClosestElementWithDataAttribute`
  / `.closest('button')` walk in `finalizeGesture` that resolves the
  drop target when the pointer lands on a child node (TouchRipple span,
  text content).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@LukasTy
LukasTy merged commit c772722 into mui:master May 19, 2026
21 checks passed
@LukasTy
LukasTy deleted the claude/kind-hofstadter-ba1396 branch May 19, 2026 09:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

plan: Pro Impact at least one Pro user. scope: pickers Changes related to the date/time pickers. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants